/*
 * Copyright 2013 JROSE
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jrose.core.jdbc.persistence.query;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.jrose.core.jdbc.entity.EntityDefinition;
import org.jrose.core.jdbc.entity.field.PersistenceField;
import org.jrose.core.jdbc.entity.field.internal.PrimaryKeyField;
import org.jrose.core.util.CollectionUtils;
import org.jrose.core.util.ConversionUtils;
import org.jrose.core.util.StringFormatter;
import org.springframework.core.convert.ConversionService;
import org.springframework.data.domain.Sort;
import org.springframework.util.Assert;

import java.io.Serializable;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

@Slf4j
public class Query<E extends Serializable> implements Serializable {
    private static final long serialVersionUID = 6213220731410104261L;
    private static final ConversionService conversionService = ConversionUtils.getConversionService();

    private final List<Criteria> localCriteriaList = new LinkedList<>();
    private final List<Sort.Order> localOrderByList = new LinkedList<>();
    private Serializable[] ids = null;
    private int page = 0;
    private int size = 0;
    private int limit = 0;

    public static <T extends Serializable> Query<T> query(Criteria criteria) {
        return new Query<T>().and(criteria);
    }

    public static <T extends Serializable> Query<T> query(Serializable... ids) {
        return new Query<T>().primaryKey(ids);
    }

    public Query<E> and(Criteria criteria) {
        Validate.notNull(criteria, "Criteria must not be null");

        this.localCriteriaList.add(criteria);
        return this;
    }

    public Query<E> primaryKey(Serializable... ids) {
        Validate.notNull(ids);

        this.ids = ids;
        return this;
    }

    public Query<E> orderBy(String field, Sort.Direction direction) {
        Validate.notNull(field, "Field must not be null");
        if (direction == null) direction = Sort.Direction.ASC;
        localOrderByList.add(new Sort.Order(direction, field));
        return this;
    }

    public Query<E> page(int page, int size) {
        Validate.isTrue(page >= 0);
        Validate.isTrue(size > 0);
        this.page = page;
        this.size = size;
        return this;
    }

    public Query<E> limit(int limit) {
        Validate.isTrue(limit > 0);
        this.limit = limit;
        return this;
    }

    public boolean hasCriteria() {
        return ids != null || !localCriteriaList.isEmpty();
    }

    public Sql generateQuerySQL(EntityDefinition<E> entityDefinition) {
        return generateQuerySQL(entityDefinition, false);
    }

    public Sql generateCountSQL(EntityDefinition<E> entityDefinition) {
        return generateQuerySQL(entityDefinition, false);
    }

    public Sql generateCountSQL(EntityDefinition<E> entityDefinition, boolean skipLimit) {
        return generateQuerySQL(entityDefinition, skipLimit);
    }

    private Sql generateQuerySQL(EntityDefinition<E> entityDefinition, boolean skipLimit) {
        Validate.notNull(entityDefinition);
        if (ids != null) {
            Validate.isTrue(ids.length == entityDefinition.getPrimaryKeyFields().size());

            if (!localCriteriaList.isEmpty()) {
                log.warn("All criteris will be ignored for primary key(s) specified");
                localCriteriaList.clear();
            }

            List<PrimaryKeyField> primaryKeyFields = entityDefinition.getPrimaryKeyFields();
            for (int i = 0; i < primaryKeyFields.size(); i++) {
                PrimaryKeyField primaryKeyField = primaryKeyFields.get(i);
                Object id = ids[i];
                Criteria criteria = Criteria.where(primaryKeyField.getFieldName()).is(id);
                localCriteriaList.add(criteria);
            }
        }

        Map<String, Object> namedParameters = new LinkedHashMap<>();
        String sql = "";
        // merge all local criteria
        if (!localCriteriaList.isEmpty()) {
            sql += "WHERE ";
            Criteria criteria = Criteria.and(localCriteriaList.toArray(new Criteria[localCriteriaList.size()]));
            List<String> candidates = new LinkedList<>();
            Map<String, AtomicInteger> parameterCounter = new HashMap<>();
            handleWithCriteria(entityDefinition, namedParameters, criteria, candidates, parameterCounter);
            String where = CollectionUtils.firstElement(candidates);
            if (StringUtils.startsWith(where, "(") && StringUtils.endsWith(where, ")")) {
                where = StringUtils.removeStart(where, "(");
                where = StringUtils.removeEnd(where, ")");
            }
            sql += where;
        }
        // handle with "ORDER BY"
        if (!localOrderByList.isEmpty()) {
            sql += " ORDER BY ";
            for (Sort.Order order : localOrderByList) {
                PersistenceField field = entityDefinition.getPersistenceField(order.getProperty());
                Assert.notNull(field);
                sql += field.getColumnName() + " " + order.getDirection().name() + ",";
            }
            sql = StringFormatter.trimLastComma(sql);
        }
        // handle with "PAGE" or "LIMIT"
        if (!skipLimit) {
            if (page >= 0 && size > 0) {
                long offset = page * size;
                sql += " LIMIT " + offset + "," + size;
            } else if (limit > 0) {
                sql += " LIMIT " + limit;
            }
        }

        return new Sql(Collections.singletonList(sql.trim()), namedParameters);
    }

    private void handleWithCriteria(EntityDefinition<E> entityDefinition, Map<String, Object> namedParameters, Criteria criteria, List<String> candidates, Map<String, AtomicInteger> parameterCounter) {
        if (criteria.getJoiner() == null) {
            String fieldName = criteria.getField();
            PersistenceField field = entityDefinition.getPersistenceField(fieldName);
            Assert.notNull(field);

            String columnName = field.getColumnName();
            String parameterName = generateParameterName(parameterCounter, columnName);

            String sql = criteria.generateSQL(columnName, parameterName);
            candidates.add(sql);

            if (criteria.valueAvailable()) {
                Object value = criteria.getValue();
                if (field.isEnumField() && value != null) {
                    value = conversionService.convert(value, String.class);
                }
                namedParameters.put(parameterName, value);
            }
            return;
        }

        StringBuilder sql = new StringBuilder("(");
        switch (criteria.getJoiner()) {
            case "$and": {
                List<String> list = new LinkedList<>();
                for (Criteria each : criteria.getChain()) {
                    handleWithCriteria(entityDefinition, namedParameters, each, list, parameterCounter);
                }
                sql.append(StringUtils.join(list, " AND "));
                break;
            }
            case "$or": {
                List<String> list = new LinkedList<>();
                for (Criteria each : criteria.getChain()) {
                    handleWithCriteria(entityDefinition, namedParameters, each, list, parameterCounter);
                }
                sql.append(StringUtils.join(list, " OR "));
                break;
            }
            default: {
                throw new UnsupportedOperationException();
            }
        }
        sql.append(")");
        candidates.add(sql.toString());
    }

    private String generateParameterName(Map<String, AtomicInteger> parameterCounter, String column) {
        if (parameterCounter.containsKey(column)) {
            int count = parameterCounter.get(column).getAndIncrement();
            return column + count;
        } else {
            parameterCounter.put(column, new AtomicInteger(0));
            return column;
        }
    }
}
